"""
ForgeFed plugin for Pagure.
Copyright (C) 2020-2021 zPlus <zplus@peers.community>

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.

SPDX-FileCopyrightText:  2020-2021 zPlus <zplus@peers.community>
SPDX-License-Identifier: GPL-2.0-only
"""

import celery
import flask
import functools
import json
import os
import logging
import pagure
import pagure.config
import pagure.lib.git
import pygit2
import rdflib
import requests
import requests_http_signature
import sqlalchemy
import traceback
import urllib

from . import APP_URL
from . import activitypub
from . import feeds
from . import model
from . import settings
from . import tasks

log = logging.getLogger(__name__)

log.debug('Initializing forgefed plugin...')

# This Flask Blueprint will be imported by Pagure
APP = flask.Blueprint('forgefed_ns', __name__, url_prefix='/',
                      template_folder='templates')

# TODO load Blueprint configuration from file if needed
APP.config = {}

def requires_login(func):
    """
    A decorator for routes to check user login.
    """

    @functools.wraps(func)
    def decorator(*args, **kwargs):
        if not flask.g.authenticated:
            return flask.redirect('/')
            # return ("", 401) # Unauthorized

        return func(*args, **kwargs)

    return decorator

@APP.after_request
def add_header(response):
    """
    Automatically set Content-Type header to all the Blueprint responses.
    # TODO Untangle this! Make it nicer.
    """

    # Return default headers
    if flask.request.path.startswith('/federation'):
        return response

    content_type = None

    if flask.request.path.startswith('/.well-known/host-meta'):
        content_type = 'application/xrd+xml; charset=utf-8'
    elif flask.request.path.startswith('/.well-known/webfinger'):
        content_type = 'application/jrd+json; charset=utf-8'
    elif flask.request.path.startswith('/.well-known/nodeinfo'):
        content_type = 'application/jrd+json; charset=utf-8'
    elif flask.request.path == '/nodeinfo/2.1':
        content_type = 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"'
    else:
        content_type = activitypub.default_header

    response.headers['Content-Type'] = content_type

    return response

@APP.record
def override_pagure_routes(setup_state):
    """
    We reuse Pagure routes in order to return ActivityPub objects in response
    to "Accept: application/ld+json" request headers. The "record" decorator
    registers a callback function that is called during initialization of the
    Blueprint by Flask. While Flask offers the app context during requests
    handling, the same context is not available during initialization.
    Therefore we need this callback which is called during Flask initialization
    in order to get the app context that we need to replace the Pagure views.

    See https://flask.palletsprojects.com/en/1.1.x/api/#flask.Blueprint.record
    for more info about the record() function/decorator.

    NOTE - If Flask accepted route dispatching based on headers value, we could
           just use something like @APP.route(accept='application/activity+json').
           But it does not, so we need to override the Pagure views.
         - ActivityPub requires some routes to exist for Actors, for example
           "inbox" and "followers". However, Pagure does not have suitable
           routes that we can reuse for this purpose. For this reason, those
           routes are defined directly on the Blueprint.
    """

    # Reference to the main Flask app created by Pagure and to which Blueprints
    # will be attached.
    pagure_app = setup_state.app

    # We add our templates folder to the app's jinja path, such that we can
    # override pagure templates.
    pagure_app.jinja_loader \
              .searchpath \
              .insert(0, os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates'))

    # Create a symlink to pagure templates folder. This is only used when
    # "extending" templates using {% extends "master.html" %}, because extending
    # a template with the same name will trigger an infinite recursion.
    pagure_path_symlink = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates/__pagure__')
    if not os.path.islink(pagure_path_symlink):
        os.symlink(os.path.join(settings.PAGURE_PATH, 'pagure/templates/'), pagure_path_symlink)

    """
    DEPRECATED using Pagure own database instead

    @pagure_app.before_request
    def start_database_session():
        # At the beginning of every request we get a new connection to the
        # forgefed graph store. Please note that this function is executed
        # before *every* request, for every route, including the ones
        # defined in the Blueprint.

        flask.g.forgefed = database.start_database_session()

    @pagure_app.after_request
    def do_something(response):
        return response

    @pagure_app.teardown_request
    def free_database_session(exception=None):
        # Close and remove the database session that was initiated in
        # @before_requrest. This instruction should be optional since the object
        # should be automatically garbage-collected when the request is destroyed.

        flask.g.forgefed.commit()
        flask.g.forgefed.remove()
    """

    def pagure_route(endpoint):
        """
        This function returns a decorator whose job is to replace a Pagure
        view with another one that will check the HTTP "Accept" header. If the
        HTTP request is a ActivityPub one the ForgeFed plugin will take care of it,
        otherwise it will just pass through the control to the Pagure view.

        Additional documentation useful for this decorator: https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.view_functions
        """

        def decorator(forgefed_view):
            # The Flask object "pagure_app.view_functions" contains all the
            # views defined by Pagure.
            pagure_view = pagure_app.view_functions[endpoint]

            # https://docs.python.org/3.9/library/functools.html#functools.wraps
            @functools.wraps(forgefed_view)
            def wrapper(*args, **kwargs):
                """
                DEPRECATED This is the old code that was used to parse the "Accept"
                    header manually. It's since been replaced with
                    flask.request.accept_mimetypes.best. This comment is only
                    kept here temporarily just in case there are problems with the
                    new code.

                # HTTP headers can contain multiple values separated by a comma
                accept_mimetypes = [
                    value.strip(' ')
                    for value
                    in  flask.request.accept_mimetypes.best ]

                if any(header in activitypub.headers for header in accept_mimetypes):
                    response = flask.make_response(forgefed_view(*args, **kwargs))
                    response.headers['Content-Type'] = activitypub.default_header
                    return response
                """

                if flask.request.accept_mimetypes.best in activitypub.headers:
                    response = flask.make_response(forgefed_view(*args, **kwargs))
                    response.headers['Content-Type'] = activitypub.default_header
                    return response

                # If it's not an ActivityPub request, just fall through to the
                # Pagure default view.
                return pagure_view(*args, **kwargs)

            # Replace the pagure view with our own
            pagure_app.view_functions[endpoint] = wrapper
            return wrapper

        return decorator


    ###########################################################################
    # ActivityPub Objects
    ###########################################################################

    @pagure_route('ui_ns.view_user')
    def person(*args, **kwargs):
        """
        Return a Person object from the Pagure user page.
        """

        # Retrieve path arguments
        username = kwargs.get('username')

        actor = flask.g.session \
                     .query(pagure.lib.model.User) \
                     .filter(pagure.lib.model.User.user == username) \
                     .one_or_none()

        if not actor:
            return ({}, 404)

        return activitypub.Person(actor=actor)

    @pagure_route('ui_ns.view_group')
    def group(*args, **kwargs):
        """
        Return a Pagure Group object.
        """

        # Retrieve path arguments
        group = kwargs.get('group')

        group = pagure.lib.query.search_groups(
            flask.g.session, group_name=group, group_type="user")

        if not group:
            return ({}, 404)

        return activitypub.Group(group=group)

    @pagure_route('ui_ns.view_repo')
    def project(*args, **kwargs):
        """
        Return a Project object from the project page.
        """

        # Retrieve path arguments
        username  = kwargs.get('username')
        repo      = kwargs.get('repo')
        namespace = kwargs.get('namespace')

        repository = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        project = flask.g.session.query(model.Project) \
                                 .filter(model.Project.id == repository.id) \
                                 .one_or_none()

        if not project:
            return ({}, 404)

        return activitypub.Project(project=project)

    @pagure_route('ui_ns.view_repo_git')
    @APP.route('/forks/<username>/<repo>.git')
    @APP.route('/forks/<username>/<namespace>/<repo>.git')
    def repository(repo, username=None, namespace=None, *args, **kwargs):
        """
        Return a Repository actor object.

        - Pagure has a route called "ui_ns.view_repo_git" that doesn't really do
            anything except redirect to the project URL (without ".git"). This URL
            is also used for GIT clone/push via HTTP. When a person types
            "git clone https://..." the GIT protocols appends some query arguments an
            these requests are proxied to the GIT backend. The backend uses the request
            path to match a repository on the filesystem. For example
            https://example.org/project1.git will match the folder $GIT_PROJECT_ROOT/project1.git.
            This works for projects (which in Pagure terminology are all the non-fork
            repositories) but it's broken for forks. The reason why it's broken is
            because all the forks are under the route "/fork/<username>/<projectname>.git"
            but the actual folder in the filesystem is "/forks/<username>/<projectname>.git".
            "/forks/<username>/<repo>" doesn't match any route in Pagure, but we need it
            for ForgeFed because we want the actual clonable URL to be also the Actor URL.
            Unfortunately this is not a priority for Pagure devs, and they're not gonna
            fix this "fork/forks" issue anytime soon. This is the reason why we need the
            extra routes, to match the actual clonable URL of the repository.
            NOTE This function's signature and routes are identical to the Pagure
                "ui_ns.view_repo_git", with the only exception that "/forks" has been
                added.
            TODO If they ever fix this problem, this route should be replaced with
                simply @pagure_route('ui_ns.view_repo_git')
        - The Pagure app defines a Flask before_request() function that does a
            lot of things, among which to check if there is a "repo" variable in
            the URL and in turn setup some context for the request. This happens
            for *every* request, regardless if it's about a repository or not. The
            rationale is that they rather do it this way than using a separate
            decorator for all the repositories views, because almost all requests
            are about repositories anyway. before_request() will then automatically
            return 404 if a repository does not exist, so we don't need to check
            that here because these views will never be executed.
        - The Pagure before_request() retrieves the repository using the function
            pagure.lib.query.get_authorized_project() which checks for
            authorization and, if the repo is private, it returns 404. So there
            should be no need to check for authorization here.
        """

        project = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        repository = flask.g.session.query(model.Repository) \
                            .filter(model.Repository.id == project.id) \
                            .one_or_none()

        if not repository:
            return ({}, 404)

        # This is a reference to the pygit2 repository object
        git_repository = flask.g.repo_obj

        return activitypub.Repository(project=project, repository=repository,
                                      git_repository=git_repository)

    @APP.route('/<repo>.git/refs')
    @APP.route('/<namespace>/<repo>.git/refs')
    @APP.route('/forks/<username>/<repo>.git/refs')
    @APP.route('/forks/<username>/<namespace>/<repo>.git/refs')
    def repository_refs(repo, username=None, namespace=None, *args, **kwargs):
        project = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        repository = flask.g.session.query(model.Repository) \
                            .filter(model.Repository.id == project.id) \
                            .one_or_none()

        if not repository:
            return ({}, 404)

        # This is a reference to the pygit2 repository object
        git_repository = flask.g.repo_obj

        return activitypub.Refs(repository=repository,
                                git_repository=git_repository)

    @APP.route('/<repo>.git/refs/tags/<tag>')
    @APP.route('/<namespace>/<repo>.git/refs/tags/<tag>')
    @APP.route('/forks/<username>/<repo>.git/refs/tags/<tag>')
    @APP.route('/forks/<username>/<namespace>/<repo>.git/refs/tags/<tag>')
    def repository_refs_tags(repo, tag, username=None, namespace=None, *args, **kwargs):
        project = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        repository = flask.g.session.query(model.Repository) \
                            .filter(model.Repository.id == project.id) \
                            .one_or_none()

        if not repository:
            return ({}, 404)

        # This is a reference to the pygit2 repository object
        git_repository = flask.g.repo_obj
        tag_ref = 'refs/tags/{}'.format(tag)

        if tag_ref not in git_repository.references:
            return ({}, 404)

        return activitypub.TagRef(repository=repository, ref=tag_ref)

    @pagure_route('ui_ns.view_issues')
    def tickettracker(*args, **kwargs):
        username   = kwargs.get('username')
        namespace  = kwargs.get('namespace')
        repo       = kwargs.get('repo')

        project = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        if not project:
            return ({}, 404)

        tracker = flask.g.session.query(model.TicketTracker) \
                         .filter(model.TicketTracker.id == project.id) \
                         .one_or_none()

        if not tracker:
            return ({}, 404)

        return activitypub.TicketTracker(tracker=tracker, project=project)

    @pagure_route('ui_ns.view_issue')
    def ticket(*args, **kwargs):
        username   = kwargs.get('username')
        namespace  = kwargs.get('namespace')
        repo       = kwargs.get('repo')
        issue_id   = kwargs.get('issueid')

        repository = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        if not repository:
            return ({}, 404)

        ticket = flask.g.session.query(model.Ticket) \
                                .filter(model.Ticket.id == issue_id) \
                                .filter(model.Ticket.project_id == repository.id) \
                                .one_or_none()

        if not ticket:
            return ({}, 404)

        tickettracker = flask.g.session.query(model.TicketTracker) \
                                .filter(model.TicketTracker.id == ticket.project.id) \
                                .one_or_none()

        if not tickettracker:
            return ({}, 404)

        return activitypub.Ticket(ticket=ticket, tickettracker=tickettracker)

    @pagure_route('ui_ns.view_commit')
    def commit(*args, **kwargs):
        username   = kwargs.get('username')
        namespace  = kwargs.get('namespace')
        repo       = kwargs.get('repo')
        commit_id  = kwargs.get('commitid')

        repository = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        if not repository:
            return ({}, 404)

        project = flask.g.session \
            .query(model.Project) \
            .filter(model.Project.id == repository.id) \
            .one_or_none()

        # This is a reference to the actual pygit2 repository. It's different
        # from the reference to the Pagure database object. This variable is
        # set by Pagure before_request()
        git_repository = flask.g.repo_obj

        branchname = flask.request.args.get('branch', None)
        if branchname and branchname not in git_repository.listall_branches():
            branchname = None

        try:
            commit = git_repository.get(commit_id)
        except ValueError:
            return ({}, 404)

        if commit is None:
            return ({}, 404)

        if isinstance(commit, pygit2.Blob):
            return ({}, 404)

        if isinstance(commit, pygit2.Tag):
            commit = commit.peel(pygit2.Commit)
            return flask.redirect(
                flask.url_for(
                    'ui_ns.view_commit',
                    repo=repository.name,
                    username=username,
                    namespace=repository.namespace,
                    commitid=commit.hex))

        return activitypub.Commit(project, repository, commit)

    @pagure_route('ui_ns.view_tree')
    def branch(*args, **kwargs):
        username    = kwargs.get('username')
        namespace   = kwargs.get('namespace')
        repo        = kwargs.get('repo')
        branch_name = kwargs.get('identifier')

        repository = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        if not repository:
            return ({}, 404)

        project = flask.g.session \
            .query(model.Project) \
            .filter(model.Project.id == repository.id) \
            .one_or_none()

        return activitypub.Branch(project, repository, branch_name)

    @pagure_route('ui_ns.request_pull')
    def merge_request(*args, **kwargs):
        username   = kwargs.get('username')
        namespace  = kwargs.get('namespace')
        repo       = kwargs.get('repo')
        request_id = kwargs.get('requestid')

        repository = pagure.lib.query.get_authorized_project(
            flask.g.session, repo, user=username, namespace=namespace)

        if not repository:
            return ({}, 404)

        mergerequest = flask.g.session \
                              .query(model.MergeRequest) \
                              .filter(model.MergeRequest.id == request_id,
                                      model.MergeRequest.project_id == repository.id) \
                              .one_or_none()

        if not mergerequest:
            return ({}, 404)

        return activitypub.MergeRequest(mergerequest)

    log.info('forgefed plugin registered by Flask.')




# WebFinger
#
# This is primarily used to support other ActivityPub software such as
# Mastodon that relies on webfinger as a discovery protocol because users
# use @username@domain when mentioning other users.
# https://docs.joinmastodon.org/spec/webfinger/
# -----------------------------------------------------------------------------

@APP.route('/.well-known/host-meta', methods=['GET'])
def host_meta():
    return flask.render_template('host-meta.xml', APP_URL=APP_URL)

@APP.route('/.well-known/webfinger/<path:uri>', methods=['GET'])
def webfinger_resource(uri):
    """
    Return the webfinger info for account "uri".

    :param uri: The "acct:userpard@host" to search.
    """

    # Only support acct: resources
    # Do we need to support other schemes? WebFinger is neutral regarding the
    # scheme of URI: it could be "acct", "http", "https", "mailto", or some
    # other scheme, but other AcitivityPub instances such as Mastodon only
    # use "acct".
    if not uri.startswith('acct:'):
        return ({}, 404)

    # The "acct" scheme is defined in the spec as
    #    "acct" ":" userpart "@" host
    # "host" is the domain where the account is hosted
    # "userpart" contains the localinfo used by the host to retrieve the account
    userpart, host = uri[5:].rsplit('@', 1)

    # Now we find the actual actor's URI.
    # The "userpart" is basically the "preferredUsername" property defined in
    # model.py
    if userpart.startswith('project/'):
        actor_uri = '{}/{}'.format(APP_URL, userpart[8:])
    else:
        actor_uri = '{}/user/{}'.format(APP_URL, userpart)

    return flask.render_template('webfinger.json', subject=uri, actor_uri=actor_uri)




# NodeInfo
#
# This is used for supporting the NodeInfo discoverability protocol.
# http://nodeinfo.diaspora.software/schema.html
# There was a proposal for supporting ServiceInfo too. ServiceInfo is a fork of
# NodeInfo, and should be an improved version of the former. However, the spec
# has stalled and it's not been developed to completion.
# -----------------------------------------------------------------------------

@APP.route('/.well-known/nodeinfo', methods=['GET'])
def nodeinfo():
    """
    Return the JRD document as dictated by the NodeInfo specification.
    """

    return {
        'links': [
            {
                'rel':  'http://nodeinfo.diaspora.software/ns/schema/2.1',
                'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_1'))
            },
            {
                'rel':  'http://nodeinfo.diaspora.software/ns/schema/2.0',
                'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_0'))
            },
            {
                'rel':  'http://nodeinfo.diaspora.software/ns/schema/1.1',
                'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_1'))
            },
            {
                'rel':  'http://nodeinfo.diaspora.software/ns/schema/1.0',
                'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_0'))
            }
        ]
    }

@APP.route('/nodeinfo/2.1', methods=['GET'])
def nodeinfo2_1():
    """
    Return the NodeInfo document v2.1
    """

    # In order to get the real number of users of this instance we need to subtract
    # the accounts used for remote users.

    # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
    total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()

    return {
        'version': '2.1',
        'software': {
            'name': 'Pagure',
            'version': pagure.__version__,
            'repository': 'https://pagure.io/pagure',
            'homepage': 'https://pagure.io'
        },
        'protocols': [ 'activitypub', 'forgefed' ],
        'services': {
            'inbound': [],
            'outbound': []
        },
        'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
        'usage': {
            'users': {
                'total': total_users,
                # 'activeHalfyear': 0,
                # 'activeMonth': 0
            },
            # 'localPosts': 0,
            # 'localComments': 0
        },
        'metadata': {}
    }

@APP.route('/nodeinfo/2.0', methods=['GET'])
def nodeinfo2_0():
    """
    Return the NodeInfo document v2.0
    """

    # In order to get the real number of users of this instance we need to subtract
    # the accounts used for remote users.

    # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
    total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()

    return {
        'version': '2.0',
        'software': {
            'name': 'Pagure',
            'version': pagure.__version__
        },
        'protocols': [ 'activitypub', 'forgefed' ],
        'services': {
            'inbound': [],
            'outbound': []
        },
        'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
        'usage': {
            'users': {
                'total': total_users,
                # 'activeHalfyear': 0,
                # 'activeMonth': 0
            },
            # 'localPosts': 0,
            # 'localComments': 0
        },
        'metadata': {}
    }

@APP.route('/nodeinfo/1.1', methods=['GET'])
def nodeinfo1_1():
    """
    Return the NodeInfo document v1.1
    """

    # In order to get the real number of users of this instance we need to subtract
    # the accounts used for remote users.

    # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
    total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()

    return {
        'version': '1.1',
        'software': {
            'name': 'Pagure',
            'version': pagure.__version__
        },
        'protocols': {
            'inbound': [ 'activitypub', 'forgefed' ],
            'outbound': [ 'activitypub', 'forgefed' ]
        },
        'services': {
            'inbound': [],
            'outbound': []
        },
        'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
        'usage': {
            'users': {
                'total': total_users,
                # 'activeHalfyear': 0,
                # 'activeMonth': 0
            },
            # 'localPosts': 0,
            # 'localComments': 0
        },
        'metadata': {}
    }

@APP.route('/nodeinfo/1.0', methods=['GET'])
def nodeinfo1_0():
    """
    Return the NodeInfo document v1.0
    """

    # In order to get the real number of users of this instance we need to subtract
    # the accounts used for remote users.

    # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
    total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()

    return {
        'version': '1.0',
        'software': {
            'name': 'Pagure',
            'version': pagure.__version__
        },
        'protocols': {
            'inbound': [ 'activitypub', 'forgefed' ],
            'outbound': [ 'activitypub', 'forgefed' ]
        },
        'services': {
            'inbound': [],
            'outbound': []
        },
        'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
        'usage': {
            'users': {
                'total': total_users,
                # 'activeHalfyear': 0,
                # 'activeMonth': 0
            },
            # 'localPosts': 0,
            # 'localComments': 0
        },
        'metadata': {}
    }




# Routes used to interact with remote objects of the federation, when we
# cannot reuse another Pagure route.
# -----------------------------------------------------------------------------

@APP.route('/federation', methods=['GET'])
@requires_login
def federation():
    """
    Entry page for federation.
    """

    person = flask.g.session \
                  .query(model.Person) \
                  .filter(model.Person.id == flask.g.fas_user.id) \
                  .one_or_none()

    if not person:
        return flask.redirect('/')

    # Retrieve feeds of the current user
    items = flask.g.session \
                   .query(model.Feed) \
                   .filter(model.Feed.actor_uri == person.uri) \
                   .order_by(model.Feed.created.desc()) \
                   .all()

    # A feed "content" property contains a serialized Python dictionary.
    # We convert strings back to dictionary before rendering the template.
    feed_items = [
        { 'created': feed.created, 'content': json.loads(feed.content) }
        for feed in items
    ]

    return flask.render_template('federation/feeds.html', feeds=feed_items)

@APP.route('/federation/activity/<id>', methods=['GET'])
def federation_activity(id):
    """
    Return the JSON representation of Activities created by this instance.
    """

    resource = flask.g.session \
                    .query(model.Resource) \
                    .filter(model.Resource.uri == '{}/federation/activity/{}'.format(APP_URL, id)) \
                    .one_or_none()

    if not resource:
        return ({}, 404)

    activity = json.loads(resource.document)

    return activity

@APP.route('/federation/followers', methods=['GET'])
@requires_login
def federation_followers():
    user = flask.g.session \
                .query(model.Person) \
                .filter(model.Person.id == flask.g.fas_user.id) \
                .one_or_none()

    if not user:
        return flask.redirect(flask.url_for('forgefed_ns.federation'))

    person = activitypub.fetch(user.uri)

    # Retrieve "following" for the current user
    items = flask.g.session \
                   .query(model.Collection, model.Resource) \
                   .join(model.Resource, model.Collection.item == model.Resource.uri) \
                   .filter(model.Collection.uri == person['followers']) \
                   .all()

    items = [ json.loads(resource.document) for collection, resource in items ]
    items = sorted(items, key=lambda i: i['name'].casefold())

    return flask.render_template('federation/followers.html', followers=items)

@APP.route('/federation/following', methods=['GET'])
@requires_login
def federation_following():
    user = flask.g.session \
                .query(model.Person) \
                .filter(model.Person.id == flask.g.fas_user.id) \
                .one_or_none()

    if not user:
        return flask.redirect(flask.url_for('forgefed_ns.federation'))

    person = activitypub.fetch(user.uri)

    # Retrieve "following" for the current user
    items = flask.g.session \
                 .query(model.Collection, model.Resource) \
                 .join(model.Resource, model.Collection.item == model.Resource.uri) \
                 .filter(model.Collection.uri == person['following']) \
                 .all()

    items = [ json.loads(resource.document) for collection, resource in items ]
    items = sorted(items, key=lambda i: i['name'].casefold())

    return flask.render_template('federation/following.html', following=items)

@APP.route('/federation/search', methods=['GET'])
@requires_login
def federation_search():
    uri = flask.request.args.get('uri')
    result = None

    # Search for an object
    if uri:
        result = activitypub.fetch(uri)

    return flask.render_template(
        'federation/search.html',
        uri=uri,
        result=result)

@APP.route('/federation/follow', methods=['GET'])
@requires_login
def federation_follow():
    remote_actor_uri = flask.request.args.get('actor_uri')

    # The user that clicked the "Follow" button
    person = flask.g.session \
                .query(model.Person) \
                .filter(model.Person.id == flask.g.fas_user.id) \
                .one_or_none()

    # The user that clicked "Follow" doesn't exist?!
    if not person:
        return flask.redirect(flask.url_for('forgefed_ns.federation'))

    person_jsonld = activitypub.fetch(person.local_uri)
    remote_actor = activitypub.fetch(remote_actor_uri)

    if not remote_actor:
        raise Exception('Could not fetch remote actor.')

    flask.g.session.add(model.Feed(person.uri, feeds.follow(person_jsonld, remote_actor)))

    # Send a notification because Pagure doesn't have any notification for
    # this event.
    pagure.lib.notify.log(None, 'forgefed.follow', {
        'follower': person_jsonld,
        'followed': remote_actor})

    flask.g.session.commit()

    return flask.redirect(flask.url_for('forgefed_ns.federation'))

@APP.route('/federation/submit_ticket', methods=['GET'])
@requires_login
def federation_submit_ticket():
    # The URL of the remote tracker (actor)
    tickettracker_uri = flask.request.args.get('actor_uri')

    if not tickettracker_uri:
        return flask.redirect(flask.url_for('ui_ns.index'))

    # Retrieve the remote actor
    tickettracker_jsonld = activitypub.fetch(tickettracker_uri)

    if not tickettracker_jsonld:
        log.debug('Cannot submit Ticket because cannot fetch remote TicketTracker.')
        flask.abort()

    # Check if the TicketTracker already exists
    tickettracker = model.from_uri(flask.g.session, tickettracker_uri)

    # TicketTracker already exists:
    if tickettracker:
        if not isinstance(tickettracker, model.TicketTracker):
            log.debug('{} is not a valid TicketTracker.'.format(tickettracker_uri))
            flask.abort()

        return flask.redirect(flask.url_for('ui_ns.new_issue',
                                            repo=tickettracker.name,
                                            username=tickettracker.user.username if tickettracker.is_fork else None,
                                            namespace=tickettracker.namespace))

    # TicketTracker doesn't exist
    log.debug('Creating TicketTracket for remote object {}'.format(tickettracker_uri))

    # The user that clicked the "Open ticket" button
    person = flask.g.session \
                .query(model.Person) \
                .filter(model.Person.id == flask.g.fas_user.id) \
                .one_or_none()

    if not person:
        return flask.redirect(flask.url_for('forgefed_ns.federation'))

    # Create a local user for the project because in Pagure a project must
    # have a user ID
    forgefed_user = test_or_set_forgefed_user(flask.g.session)

    project_name = 'forgefed:tracker:{}@{}'.format(
        model.safe_uri(tickettracker_jsonld['name']),
        model.safe_uri(urllib.parse.urlparse(tickettracker_uri).netloc))

    # Create the project in the Pagure database.
    # This function will create the item in the database, and then will
    # start a new async task to create the actual .git folder.
    task = pagure.lib.query.new_project(
        session=flask.g.session,
        user=forgefed_user.username,
        name=project_name,
        blacklist=[],
        allowed_prefix=[],
        repospanner_region=None,
        description='Remote tracker.',
        url=tickettracker_uri,
        avatar_email=None,
        parent_id=None,
        add_readme=False,
        namespace=None,
        user_ns=False,
        ignore_existing_repo=False,
        private=False,
    )

    # We need to wait for the project to be created, then we redirect to the
    # project's issues page
    task_result = task.wait(timeout=None, interval=0.5)

    tickettracker = flask.g.session \
                           .query(model.TicketTracker) \
                           .filter(model.TicketTracker.name == project_name) \
                           .one_or_none()

    # Disable pull requests for this project, since it's only used as a
    # ticket tracker
    tickettracker.settings = {
        **tickettracker.settings,
        'issue_tracker': True,
        'project_documentation': False,
        'pull_requests': False,
        'pull_request_access_only': True,
        'issue_tracker_read_only': False,
    }

    # The tracker that we've just created is only used to interact with a
    # remote one. It's not a "real" local tracker by a local user, so we
    # create a SameAs relation in the graph.
    #tracker_url = '{}/{}/issues'.format(APP_URL, user.username)
    tickettracker.remote_uri = tickettracker_uri

    # Make sure that this User is following the tracker, so that per will receive
    # notifications
    activity = activitypub.Activity(
        type   = 'Follow',
        actor  = person.uri,
        object = tickettracker.remote_uri,
        to     = tickettracker.remote_uri)
    activity.distribute()

    flask.g.session.commit()

    # Redirect to the "new issue" page of the local tracker, where the user can
    # create a new issue for the remote tracker.
    return flask.redirect(flask.url_for('ui_ns.new_issue', repo=project_name))

@APP.route('/federation/ticket/<issue_uid>/comments', methods=['GET'])
def federation_ticket_comments(issue_uid):
    """
    Return the Collection containing a ticket's comments.
    This route exists because pagure does not have any route defined for comments.
    The path of a comment in pagure is /<repo>/issue/<issueid>#comment-<commend_id>
    but the fragment of the URL (after the # symbol) is not sent to the server.

    :param issue_uid: The unique ID defined by pagure during ticket creation. This
        is used instead of the default key which is (project_id, issue_id).
    """

    collection_uri = APP_URL + flask.request.path

    return activitypub.OrderedCollection(collection_uri)

@APP.route('/federation/ticket/<issue_uid>/comments/<int:page>', methods=['GET'])
def federation_ticket_comments_page(issue_uid):
    """
    Return the CollectionPage containing a ticket's comments.

    :param issue_uid: The unique ID defined by pagure during ticket creation. This
        is used instead of the default key which is (project_id, issue_id).

    :param page: The page of the collection to show.
    """

    page_uri = APP_URL + flask.request.path
    collection_uri = page_uri.rsplit('/', 1)[0]

    # Retrieve items
    items = flask.g.session \
                   .query(model.TicketComment) \
                   .filter(model.TicketComment.issue_uid == issue_uid) \
                   .offset(settings.COLLECTION_SIZE * page) \
                   .limit(settings.COLLECTION_SIZE) \
                   .all()

    items_ids = [ result.local_uri for result in items ]

    return {
        '@context': activitypub.jsonld_context,
        'type': 'OrderedCollectionPage',
        'id': page_uri,
        'partOf': collection_uri,
        'orderedItems': items_ids }

@APP.route('/federation/ticket_comment/<comment_id>', methods=['GET'])
def federation_ticket_comment(comment_id):
    """
    Return the JSONLD of a ticket comment. This is required because Pagure
    doesn't have any route dedicated to show a single comment.

    :param comment_id: The comment ID defined by pagure during comment creation.
        This is the default primary key of the comment and is unique across all
        local issues.
    """

    comment = flask.g.session \
                   .query(model.TicketComment) \
                   .filter(model.TicketComment.id == comment_id) \
                   .one_or_none()

    return activitypub.TicketComment(comment=comment)

@APP.route('/federation/fork', methods=['GET'])
@requires_login
def federation_fork():
    # The URL of the remote tracker (actor)
    repository_uri = flask.request.args.get('repository_uri')

    if not repository_uri:
        return flask.redirect(flask.url_for('ui_ns.index'))

    # The user that clicked the "Fork" button
    person = flask.g.session \
                .query(model.Person) \
                .filter(model.Person.id == flask.g.fas_user.id) \
                .one_or_none()

    if not person:
        return flask.redirect(flask.url_for('forgefed_ns.federation'))

    # Retrieve the remote actor
    repository_jsonld = activitypub.fetch(repository_uri)

    if not repository_jsonld:
        log.debug('Cannot fork because cannot fetch remote Repository.')
        flask.abort()

    # Check if the Repository already exists
    repository = model.from_uri(flask.g.session, repository_uri)

    # Repository already exists?
    if repository:

        if not isinstance(repository, model.Repository):
            log.debug('{} is not a valid Repository.'.format(repository_uri))
            flask.abort()

    else:

        log.debug('Creating Repository for remote object {}'.format(repository_uri))

        # Create a local user for the project because in Pagure a project must
        # have a user ID
        forgefed_user = test_or_set_forgefed_user(flask.g.session)

        project_name = 'forgefed:repository:{}@{}'.format(
            model.safe_uri(repository_jsonld['name']),
            model.safe_uri(urllib.parse.urlparse(repository_uri).netloc))

        # Create the project in the Pagure database.
        # This function will create the item in the database, and then will
        # start a new async task to create the actual .git folder.
        task = pagure.lib.query.new_project(
            session=flask.g.session,
            user=forgefed_user.username,
            name=project_name,
            blacklist=[],
            allowed_prefix=[],
            repospanner_region=None,
            description='Remote repository.',
            url=repository_uri,
            avatar_email=None,
            parent_id=None,
            add_readme=False,
            mirrored_from=repository_uri,
            namespace=None,
            user_ns=False,
            ignore_existing_repo=False,
            private=False,
        )

        # We need to wait for the project to be created, then we redirect to the
        # project's issues page
        task_result = task.wait(timeout=None, interval=0.5)

        repository = flask.g.session \
                            .query(model.Repository) \
                            .filter(model.Repository.name == project_name,
                                    model.Repository.is_fork == False) \
                            .one_or_none()

        if not repository:
            log.critical('Project repository was not created before forking.')
            flask.abort(500)

        # Disable tickets for this project, since it's only used as a mirror
        repository.settings = {
            **repository.settings,
            'issue_tracker': False,
            'project_documentation': False,
            'pull_requests': True,
            'pull_request_access_only': True,
            'issue_tracker_read_only': True,
        }

        # The repository that we've just created is only used to interact with a
        # remote one. It's not a "real" local repository by a local user, so we
        # create a SameAs relation in the graph.
        repository.remote_uri = repository_uri

    # Redirect to the user fork if the user already forked this repository
    project_fork = pagure.lib.query._get_project(
        session=flask.g.session,
        name=repository.name,
        user=person.username,
        namespace=repository.namespace)

    if project_fork:
        return flask.redirect(
            flask.url_for(
                'ui_ns.view_repo',
                repo=project_fork.name,
                username=person.username,
                namespace=project_fork.namespace))

    log.debug('Forking {} for user {}'.format(repository.uri, person.uri))

    # TODO This mirror is happening synchronously. How can I use Pagure "wait
    # screen"?
    if repository.is_remote:
        pagure.lib.git.mirror_pull_project(flask.g.session, repository)

    task = pagure.lib.query.fork_project(
        session=flask.g.session, repo=repository, user=person.username)

    task_result = task.wait(timeout=None, interval=0.5)

    # Make sure that this User is following the tracker, so that per will receive
    # notifications
    activitypub.Activity(
        type   = 'Follow',
        actor  = person.uri,
        object = repository.remote_uri,
        to     = repository.remote_uri
    ).distribute()

    flask.g.session.commit()

    # Redirect to the user fork
    return flask.redirect(
        flask.url_for(
            'ui_ns.view_repo',
            repo=repository.name,
            username=person.username,
            namespace=repository.namespace))

@APP.route('/federation/mergerequest/<mergerequest_uid>/comments', methods=['GET'])
def federation_mergerequest_comments(mergerequest_uid):
    """
    Return the Collection containing a MergeRequest's comments.
    This route exists because pagure does not have any route defined for comments.

    :param mergerequest_uid: The unique ID defined by Pagure when it creates a
        PullRequest. This is used instead of the default primary_key which is the
        tuple (id, project_id).
    """

    collection_uri = APP_URL + flask.request.path

    return activitypub.OrderedCollection(collection_uri)

@APP.route('/federation/mergerequest/<mergerequest_uid>/comments/<int:page>', methods=['GET'])
def federation_mergerequest_comments_page(mergerequest_uid):
    """
    Return the CollectionPage containing a MergeRequest's comments.

    :param mergerequest_uid: The unique ID defined by Pagure when it creates a
        PullRequest. This is used instead of the default primary_key which is the
        tuple (id, project_id).

    :param page: The page of the collection to show.
    """

    page_uri = APP_URL + flask.request.path
    collection_uri = page_uri.rsplit('/', 1)[0]

    # Retrieve items
    items = flask.g.session \
                   .query(model.MergeRequestComment) \
                   .filter(model.MergeRequestComment.pull_request_uid == mergerequest_uid) \
                   .offset(settings.COLLECTION_SIZE * page) \
                   .limit(settings.COLLECTION_SIZE) \
                   .all()

    items_ids = [ result.local_uri for result in items ]

    return {
        '@context': activitypub.jsonld_context,
        'type': 'OrderedCollectionPage',
        'id': page_uri,
        'partOf': collection_uri,
        'orderedItems': items_ids }

@APP.route('/federation/mergerequest_comment/<comment_id>', methods=['GET'])
def federation_mergerequest_comment(comment_id):
    """
    Return the JSONLD of a MergeRequest comment. This is required because Pagure
    doesn't have any route dedicated to show a single comment.

    :param comment_id: The comment ID defined by Pagure during comment creation.
        This is the default primary key of the comment and is unique across all
        local pull-requests.
    """

    comment = flask.g.session \
                   .query(model.MergeRequestComment) \
                   .filter(model.MergeRequestComment.id == comment_id) \
                   .one_or_none()

    return activitypub.MergeRequestComment(comment=comment)




# Repository
# ----------

@APP.route('/federation/tag/<id>', methods=['GET'])
def project_tag(id):
    """
    Display a Tag object.
    """

    tag = flask.g.session \
        .query(model.Tag) \
        .filter(model.Tag.id == id) \
        .one_or_none()

    if not tag:
        return ({}, 404)

    return activitypub.Tag(tag=tag)




# Actor
# -----

@APP.route('/<path:actor>/key.pub', methods=['GET'])
def actor_key(actor):
    """
    This object represents the public GPG key used to sign HTTP requests.
    """

    actor_path = '/' + actor
    actor_uri  = APP_URL + '/' + actor
    actor      = model.from_path(flask.g.session, actor_path)
    key_uri    = APP_URL + flask.request.path

    if not actor:
        return ({}, 404)

    # Create key if it doesn't exist
    model.GpgKey.test_or_set(flask.g.session, actor_uri, key_uri)

    # Get the key
    key = flask.g.session.query(model.GpgKey) \
                         .filter(model.GpgKey.uri == key_uri) \
                         .one_or_none()

    return activitypub.CryptographicKey(key=key)

@APP.route('/<path:actor>/sshkey/<path:key>', methods=['GET'])
def actor_ssh_key(actor, key):
    """
    Return an Actor's SSH key.

    :param key: An SSH key identifier as created by Pagure when users insert their
        public keys in the user settings. This corresponds to the SSHKey.ssh_search_key
        property in the Pagure model.
    """

    actor_path = '/' + actor
    actor_uri  = APP_URL + '/' + actor
    actor      = model.from_path(flask.g.session, actor_path)
    key_uri    = APP_URL + flask.request.path

    if not actor:
        return ({}, 404)

    # Get the key
    key = flask.g.session \
                 .query(model.SshKey) \
                 .filter(model.SshKey.ssh_search_key == key) \
                 .one_or_none()

    if not key:
        return ({}, 404)

    return activitypub.SshKey(key=key)

@APP.route('/<path:project>/role/<role>', methods=['GET'])
def project_roles(project, role):
    """
    Returns a Role.
    """

    role = flask.g.session \
        .query(pagure.lib.model.AccessLevels) \
        .filter(pagure.lib.model.AccessLevels.access == role) \
        .one_or_none()

    if not role:
        log.debug('The Role {} does not exist.'.format(role))
        return ({}, 404)

    project_path = '/' + project
    project_uri  = APP_URL + project_path
    project = model.from_uri(flask.g.session, project_uri)

    if not project:
        log.debug('The Project {} does not exist.'.format(project_uri))
        return ({}, 404)

    return activitypub.Role(project=project, role=role)

@APP.route('/<path:actor>/inbox', methods=['GET'])
def actor_inbox(actor):
    """
    Returns an Actor's INBOX.
    This should only be called by the Actors who want to read their own INBOX.
    """

    return ({}, 501) # 501 Not Implemented. TODO implement C2S.

@APP.route('/<path:actor>/outbox', methods=['GET'])
def actor_outbox(actor, page=None):
    """
    Returns an Actor's OUTBOX.
    This should only be called by the Actors who want to read their own OUTBOX.
    """

    # TODO Show only "Public" OUTBOX
    # https://www.w3.org/TR/activitypub/#public-addressing

    return ({}, 501) # 501 Not Implemented. TODO implement C2S.

@APP.route('/<path:actor>/inbox', methods=['POST'])
def actor_receive(actor):
    """
    Somebody is sending a message to an actor's INBOX.
    """

    actor_path = '/' + actor
    actor_uri  = APP_URL + '/' + actor

    # Retrieve the ActivityPub Activity from the HTTP request body. The
    # Activity is expected to be a JSON object.
    # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
    activity = flask.request.get_json()
    activity = activitypub.Activity.from_dict(activity)

    # Verify the HTTP signature of the incoming request. The HTTP POST request
    # must have been signed with the Actor public key.
    class SignatureMismatch(Exception):
        def __init__(self, key_id, actor_id):
            """
            :param key_id: The key ID in the HTTP signature

            :param actor_id: The ID of the Activity Actor.
            """

            self.key_id       = key_id
            self.actor_id     = actor_id

        def __str__(self):
            return 'HTTP request signed by a different Actor:\n' + \
                   '    key_id: {}\n'.format(self.key_id) + \
                   '    Actor:  {}\n'.format(self.actor_id)

    def key_resolver(key_id, algorithm):
        # Fetch the remote Actor's key
        remote_actor_key = activity.node('actor').node('publicKey')

        if key_id != activity['actor']['id']:
            raise SignatureMismatch(key_id, activity['actor']['id'])

        # .encode() will encode the UTF-8 string to bytes. We need this because
        # the 'publicKeyPem' retrieved from the ActivityPub JSON-LD is in UTF-8
        # format but the requests_http_signature module requires raw bytes data.
        return remote_actor_key['publicKeyPem'].encode()

    signature_verified = False

    try:
        requests_http_signature.HTTPSignatureAuth.verify(
            flask.request, key_resolver=key_resolver, scheme='Signature')

    except SignatureMismatch:
        """
        If the HTTP Signature key_id does not match the Actor ID, it means that
        the Activity was POSTed by a different Actor than the Activity actual Actor.
        This probably means that the Activity was forwarded (unless it was forged!).
        """
        pass

    except Exception as e:
        log.error(e)
        log.error('Could not verify HTTP signature for incoming Activity'.format(activity['id']))
        return ({}, 400) # Bad Request

    else:
        log.debug('HTTP signature for incoming Activity {} verified.'.format(activity['id']))
        signature_verified = True

    # Schedule a task to process the incoming activity asynchronously
    tasks.delivery.validate_incoming_activity.delay(actor_uri, activity, signature_verified)

    return ({}, 202) # 202 Accepted

@APP.route('/<path:actor>/outbox', methods=['POST'])
def actor_send(actor):
    """
    An Actor is trying to POST an Activity to its OUTBOX.
    This should only be called by an Actor's client that want to send out a new
    Activity.
    """

    # TODO
    log.debug('Implement bearer token authentication for 3rd party clients')
    return ({}, 501) # 501 Not Implemented

    actor_uri = APP_URL + '/' + actor

    # Retrieve the ActivityPub Activity from the HTTP request body. The
    # Activity is expected to be a JSON object.
    # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
    activity = flask.request.get_json()

    # Schedule a task to process the incoming activity asynchronously
    tasks.delivery.validate_outgoing_activity.delay(actor_uri, activity)

@APP.route('/<path:actor>/followers', methods=['GET'])
def actor_followers(actor):
    """
    Show the followers of an actor.
    """

    actor_path = '/' + actor
    actor      = model.from_path(flask.g.session, actor_path)

    if not actor:
        return ({}, 404)

    collection_uri = APP_URL + flask.request.path

    return activitypub.OrderedCollection(collection_uri=collection_uri)

@APP.route('/<path:actor>/followers/<int:page>', methods=['GET'])
def actor_followers_page(actor, page):
    """
    Show the followers of an actor.
    """

    page_uri = APP_URL + flask.request.path
    collection_uri = page_uri.rsplit('/', 1)[0]

    # Retrieve items
    items = flask.g.session \
                 .query(model.Collection) \
                 .filter(model.Collection.uri == collection_uri) \
                 .order_by(model.Collection.added.desc()) \
                 .offset(settings.COLLECTION_SIZE * page) \
                 .limit(settings.COLLECTION_SIZE) \
                 .all()

    items_uri = [ result.item for result in items ]

    return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
                                             page_uri=page_uri,
                                             items=items_uri)

@APP.route('/<path:actor>/following', methods=['GET'])
def actor_following(actor):
    """
    Show the actors that an actor is following.
    """

    actor_path = '/' + actor
    actor      = model.from_path(flask.g.session, actor_path)

    if not actor:
        return ({}, 404)

    collection_uri = APP_URL + flask.request.path

    return activitypub.OrderedCollection(collection_uri=collection_uri)

@APP.route('/<path:actor>/following/<int:page>', methods=['GET'])
def actor_following_page(actor, page):
    """
    Show the actors that an actor is following.
    """

    page_uri = APP_URL + flask.request.path
    collection_uri = page_uri.rsplit('/', 1)[0]

    # Retrieve items
    items = flask.g.session \
                   .query(model.Collection) \
                   .filter(model.Collection.uri == collection_uri) \
                   .order_by(model.Collection.added.desc()) \
                   .offset(settings.COLLECTION_SIZE * page) \
                   .limit(settings.COLLECTION_SIZE) \
                   .all()

    items_uri = [ result.item for result in items ]

    return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
                                             page_uri=page_uri,
                                             items=json.dumps(items_uri))

log.debug('forgefed plugin initialized.')
